Skip to content

Rate limit and brute force protection. Some updates to landing page a…#103

Merged
LinusWestling merged 6 commits into
mainfrom
security
Apr 24, 2026
Merged

Rate limit and brute force protection. Some updates to landing page a…#103
LinusWestling merged 6 commits into
mainfrom
security

Conversation

@LinusWestling

@LinusWestling LinusWestling commented Apr 24, 2026

Copy link
Copy Markdown
Collaborator

Closes #89

…swell

Summary by CodeRabbit

Release Notes

  • New Features

    • Login brute-force protection: Accounts temporarily lock after repeated failed login attempts
    • API rate limiting: Requests exceeding limits receive "Too many requests" responses with retry guidance
    • Enhanced landing page with slide-based navigation and smooth transitions
  • Style

    • Removed emoji decorations from UI elements for cleaner appearance
    • Improved landing page animations and visual effects
  • Chores

    • Added application health and metrics monitoring endpoints

@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@LinusWestling has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 36 minutes and 31 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 36 minutes and 31 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 58f30dd7-0ea1-46d2-90b4-abc5074717ec

📥 Commits

Reviewing files that changed from the base of the PR and between e46e3e4 and 76d093b.

📒 Files selected for processing (14)
  • pom.xml
  • src/main/java/org/example/projektarendehantering/infrastructure/security/ClientIpResolver.java
  • src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptService.java
  • src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationFailureHandler.java
  • src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationSuccessHandler.java
  • src/main/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilter.java
  • src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilter.java
  • src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitService.java
  • src/main/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityService.java
  • src/main/resources/application.properties
  • src/main/resources/static/app.css
  • src/main/resources/static/app.js
  • src/test/java/org/example/projektarendehantering/infrastructure/security/ClientIpResolverTest.java
  • src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptServiceTest.java
📝 Walkthrough

Walkthrough

The pull request adds Spring Boot Actuator dependency and implements comprehensive authentication and rate-limiting security infrastructure, including rate-limit filters with configurable policies, login brute-force prevention with account lockout, custom authentication handlers, login attempt tracking, Micrometer observability metrics, and updates the landing page UI to replace emoji with slide-based navigation.

Changes

Cohort / File(s) Summary
Dependencies & Configuration
pom.xml, src/main/resources/application.properties
Adds Spring Boot Actuator dependency and introduces configuration properties for global API and auth-endpoint rate limits, login failure thresholds/windows, and Actuator endpoint exposure (health, info, metrics, prometheus).
Rate Limiting Infrastructure
src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilter.java, src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitService.java
Introduces servlet filter that intercepts requests and enforces per-policy fixed time-window rate limits, delegating decisions to RateLimitService which maintains concurrent per-(policy, clientIp) counters and returns allow/deny decisions with retry-after seconds.
Login Lockout & Attempt Tracking
src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptService.java, src/main/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilter.java, src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationSuccessHandler.java, src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationFailureHandler.java
Adds in-memory login attempt tracking with rolling-window failure counting, configurable lock threshold, and temporary account/IP lockout; integrates with Spring Security via custom filter and auth handlers that intercept login requests and record outcomes.
Security Observability
src/main/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityService.java
New service that centralizes Micrometer metrics for security events, registering counters for rate-limit denials and login outcomes (failures, locks, success resets) plus a gauge for active login locks.
Security Configuration
src/main/java/org/example/projektarendehantering/infrastructure/config/SecurityConfig.java
Updates securityFilterChain bean signature to inject rate-limit, login-lockout, and custom auth handlers; wires these into the Spring Security filter chain before form-login processing.
Landing Page UI
src/main/resources/static/app.css, src/main/resources/static/app.js, src/main/resources/templates/landing.html
Replaces IntersectionObserver-based section navigation with slide-indexed system; removes emoji from text throughout landing page; adds animated slide styling with pseudo-element effects (glow, shimmer) and responsive classes.
Login Page Template
src/main/resources/templates/login/login.html
Adds conditional UI branch to display error message when account/IP is locked during login attempt.
Test Suite
src/test/java/org/example/projektarendehantering/infrastructure/security/...Test.java (5 files)
Comprehensive unit tests covering rate limiting logic, login lockout behavior, authentication handler redirects, filter chaining, and observability metric recording, each with mocked dependencies and custom clock implementations for time-based testing.

Sequence Diagrams

sequenceDiagram
    participant Client
    participant RateLimitFilter
    participant RateLimitService
    participant LoginLockoutFilter
    participant LoginAttemptService
    participant AuthenticationManager
    participant SuccessHandler
    participant SecurityObservabilityService

    Client->>RateLimitFilter: POST /login (username, password)
    RateLimitFilter->>RateLimitService: policyForPath("/login", "POST")
    RateLimitService-->>RateLimitFilter: Optional<RateLimitPolicy>
    alt Policy matches
        RateLimitFilter->>RateLimitService: evaluate(policy, clientIp)
        RateLimitService-->>RateLimitFilter: RateLimitDecision
        alt Denied (too many requests)
            RateLimitFilter->>SecurityObservabilityService: recordRateLimitDenied(policy)
            RateLimitFilter-->>Client: HTTP 429 + Retry-After header
        else Allowed
            RateLimitFilter->>LoginLockoutFilter: filterChain.doFilter()
        end
    else No policy
        RateLimitFilter->>LoginLockoutFilter: filterChain.doFilter()
    end

    LoginLockoutFilter->>LoginAttemptService: currentLockDecision(username, clientIp)
    LoginAttemptService-->>LoginLockoutFilter: LockDecision
    alt Account/IP is locked
        LoginLockoutFilter->>SecurityObservabilityService: recordLoginLocked()
        LoginLockoutFilter-->>Client: Redirect /login?error=true&locked=true&retryAfter=N
    else Not locked
        LoginLockoutFilter->>AuthenticationManager: authenticate(credentials)
        alt Authentication succeeds
            AuthenticationManager-->>SuccessHandler: onAuthenticationSuccess()
            SuccessHandler->>LoginAttemptService: recordSuccess(username, clientIp)
            LoginAttemptService->>SecurityObservabilityService: recordLoginSuccessReset()
            SuccessHandler-->>Client: Redirect /home
        else Authentication fails
            AuthenticationManager-->>Client: AuthenticationException
            Client->>LoginAttemptService: recordFailure(username, clientIp)
            LoginAttemptService->>SecurityObservabilityService: recordLoginFailure()
            alt Failures exceed threshold
                LoginAttemptService->>SecurityObservabilityService: recordLoginLocked()
                LoginAttemptService-->>Client: LockDecision(locked=true, retryAfterSeconds=900)
            else Below threshold
                LoginAttemptService-->>Client: LockDecision(locked=false, retryAfterSeconds=0)
            end
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • OskarLundqvist33
  • mattknatt

Poem

🐰 Hops through filters, locks are tight,
Rate limits slow the brute-force night,
Slides now dance without emoji glow,
Security metrics steal the show!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the primary changes: rate limiting and brute-force protection mechanisms across multiple new security components, plus landing page UI updates (emoji removal and slide navigation).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (9)
src/main/resources/templates/login/login.html (1)

24-26: Optional: surface retryAfter in the lockout message.

LoginLockoutFilter already redirects with retryAfter=<seconds> (per the filter test). Consider showing the remaining time so users understand how long to wait, e.g.:

<div th:if="${param.locked}" class="panel panel-error" style="margin-bottom: 14px;">
    Too many login attempts. Please try again in
    <span th:text="${param.retryAfter != null ? param.retryAfter + ' seconds' : 'a few minutes'}">a few minutes</span>.
</div>

Functional as-is; this is purely a UX polish.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/templates/login/login.html` around lines 24 - 26, Update
the login template to display the lockout remaining time provided by
LoginLockoutFilter by reading the retryAfter request param instead of the static
phrase; inside the existing th:if="${param.locked}" block use param.retryAfter
(e.g. show "${param.retryAfter} seconds" when present, fallback to the current
"a few minutes" text) so users see the actual wait time returned by
LoginLockoutFilter.
src/test/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilterTest.java (1)

12-50: Consider additional coverage for non-matching requests.

Current tests cover only POST /login paths. To harden the filter's request matching, consider adding cases that exercise:

  • GET /login (should bypass the lock check),
  • requests to other paths (e.g. /register, /api/…) that should not be intercepted,
  • missing username parameter (verify no NPE and expected behavior).

Not a blocker — the happy paths are well-tested.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilterTest.java`
around lines 12 - 50, Add tests to LoginLockoutFilterTest that exercise
non-matching requests: create cases invoking LoginLockoutFilter.doFilter with
(1) a GET "/login" request (MockHttpServletRequest with method "GET") and assert
the filter does not call redirect and the chain proceeded, (2) a POST to a
different path like "/register" or "/api/foo" and assert the same, and (3) a
POST "/login" with no "username" parameter to verify no NPE and that the request
is passed through; in each test mock LoginAttemptService.currentLockDecision
only if expected to be called (and verify it is not called for non-matching
requests) and use MockFilterChain/MockHttpServletResponse assertions
(response.getRedirectedUrl() == null, response.getStatus()==200 or chain
proceeded) to confirm correct behavior.
src/main/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilter.java (1)

47-48: Nit: URLEncoder.encode on a numeric retryAfter is unnecessary.

decision.retryAfterSeconds() is a long/int; its decimal representation is URL-safe. The encoding adds no protection and marginally obscures the value in logs. Inlining decision.retryAfterSeconds() directly is cleaner (same applies in LoginAuthenticationFailureHandler Line 38).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilter.java`
around lines 47 - 48, The redirect currently URL-encodes a numeric retryAfter
value unnecessarily; in LoginLockoutFilter replace the URLEncoder.encode usage
and append decision.retryAfterSeconds() directly (or its String form) when
building the "/login?error=true&locked=true&retryAfter=" redirect, and make the
same change in LoginAuthenticationFailureHandler at the analogous location (line
~38) so the numeric seconds are inlined without encoding.
src/test/java/org/example/projektarendehantering/infrastructure/security/RateLimitServiceTest.java (1)

58-79: Optional: extract MutableClock into a shared test helper.

The same private MutableClock is reintroduced verbatim in LoginAttemptServiceTest. Consider moving it to a package-private test utility (e.g., infrastructure/security/testing/MutableClock) to avoid drift if the helper evolves.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/org/example/projektarendehantering/infrastructure/security/RateLimitServiceTest.java`
around lines 58 - 79, Extract the private MutableClock inner class used in
RateLimitServiceTest into a shared package-private test utility so
LoginAttemptServiceTest can reuse it; specifically, create a new test helper
class named MutableClock (matching the current constructor and overrides:
instant(), getZone(), withZone()) in a shared test package (e.g.,
infrastructure.security.testing) and replace the private inner MutableClock in
both RateLimitServiceTest and LoginAttemptServiceTest with imports of that
shared MutableClock. Ensure the new helper preserves the same API (constructor
taking Instant and the three overridden methods) and visibility so both tests
can access it.
src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationHandlersTest.java (1)

32-47: Optional: add a case that exercises a non-canonical username.

Both interactions use "user@example.com" verbatim, so the test cannot detect inconsistent normalization between recordFailure(request.getParameter("username"), ...) and recordSuccess(authentication.getName(), ...) (see the verification comment on LoginAuthenticationSuccessHandler). Consider a case where the form field is " User@Example.com " while the authenticated principal is "user@example.com" to assert the service key is consistent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationHandlersTest.java`
around lines 32 - 47, Add a new test that verifies normalization consistency
between the submitted username and the authentication principal: create a
LoginAuthenticationSuccessHandler with a mocked LoginAttemptService, simulate a
request whose form username parameter is a non-canonical value (e.g., " 
User@Example.com "), set request remoteAddr, build an Authentication whose
getName() returns the canonical "user@example.com", call
handler.onAuthenticationSuccess(request, response, authentication), and verify
LoginAttemptService.recordSuccess was called with the canonical username
("user@example.com") and the request IP; ensure the response redirect assertion
("/home") remains.
src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptServiceTest.java (1)

13-51: LGTM — consider optional coverage for per-IP vs per-user keying.

Solid tests for threshold, TTL expiry, and success-clears behaviour. If the service keys lockouts per (username, ip) pair (or per either), add a test asserting that failures from a different IP don't lock the same user, and/or that failures against different usernames from the same IP behave as expected. This tends to be the surface where brute-force bypasses creep in.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptServiceTest.java`
around lines 13 - 51, Add tests to verify whether LoginAttemptService keys by
(username, ip) pair or by single dimension: create new test(s) that use
LoginAttemptService (constructor used in existing tests) and call recordFailure
for "user@example.com" from "127.0.0.1" enough times to hit the threshold, then
assert currentLockDecision("user@example.com","127.0.0.2").locked() is false
(different IP) and/or assert
currentLockDecision("other@example.com","127.0.0.1").locked() is false
(different user); also add the inverse case if needed to document behavior.
Target the same class and methods: LoginAttemptService.recordFailure,
LoginAttemptService.currentLockDecision, and LoginAttemptService.recordSuccess
to show expected per-IP vs per-user semantics.
src/main/resources/static/app.js (2)

110-133: Consider throttling syncActiveSlideFromViewport.

The listener runs on every scroll event — including the scroll events emitted during a programmatic scrollIntoView from goToSlide — and each call does slides.length × getBoundingClientRect() (which forces a layout). For a handful of slides it's fine, but requestAnimationFrame-coalescing (or a simple 16ms throttle) keeps this off the main thread during inertial scrolling and avoids the brief flicker of active-link state during smooth-scroll animations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/static/app.js` around lines 110 - 133, Thottle scroll
handler by coalescing syncActiveSlideFromViewport calls with
requestAnimationFrame: add a module-level flag (e.g., isTicking) and in the
scroll listener (the window.addEventListener("scroll", ...)) only schedule
syncActiveSlideFromViewport via requestAnimationFrame when not already
scheduled, clearing the flag inside syncActiveSlideFromViewport after work
completes; ensure the listener remains passive and keep references to
slides/getBoundingClientRect logic as-is so goToSlide-triggered programmatic
scrolls are coalesced and avoid repeated forced layouts.

302-310: Simplify initial hash handling.

The first goToSlide(..., { shouldScroll: false }) is used only to sync visual state, then a second call with shouldScroll: true performs the actual scroll. Since goToSlide already runs syncBySlide() before scrolling, a single call covers both paths:

♻️ Proposed simplification
-  const hash = window.location.hash.replace("#", "");
-  const initialIndex = hash ? slideIds.indexOf(hash) : 0;
-  activeSlideIndex = initialIndex >= 0 ? initialIndex : 0;
-  goToSlide(activeSlideIndex, { behavior: "auto", shouldScroll: false, updateHash: false });
-  if (hash) {
-    goToSlide(activeSlideIndex, { behavior: "auto", shouldScroll: true, updateHash: false });
-  } else {
-    updateProgress();
-  }
+  const hash = window.location.hash.replace("#", "");
+  const initialIndex = hash ? slideIds.indexOf(hash) : 0;
+  const startIndex = initialIndex >= 0 ? initialIndex : 0;
+  goToSlide(startIndex, { behavior: "auto", shouldScroll: Boolean(hash), updateHash: false });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/static/app.js` around lines 302 - 310, Replace the two
goToSlide calls and the if/else block with a single call: determine hash and
initialIndex as before, set activeSlideIndex, then call
goToSlide(activeSlideIndex, { behavior: "auto", shouldScroll: !!hash,
updateHash: false }); and remove the redundant first call and the separate
conditional branch (including the alternate updateProgress() call) since
goToSlide runs syncBySlide() and will handle progress/scrolling appropriately.
src/main/java/org/example/projektarendehantering/infrastructure/config/SecurityConfig.java (1)

69-70: Chain the second filter relative to the first to make the execution order explicit.

Both filters are added before UsernamePasswordAuthenticationFilter. While modern Spring Security (6.x+) handles filter ordering correctly, relying on insertion order for implicit sequencing is unclear. Spring Security's documentation recommends chaining filter positions explicitly when order matters.

Make the intended RateLimitFilter → LoginLockoutFilter → UsernamePasswordAuthenticationFilter pipeline unambiguous:

Suggested refactor
-            .addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class)
-            .addFilterBefore(loginLockoutFilter, UsernamePasswordAuthenticationFilter.class);
+            .addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class)
+            .addFilterAfter(loginLockoutFilter, RateLimitFilter.class);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/projektarendehantering/infrastructure/config/SecurityConfig.java`
around lines 69 - 70, The two filters (rateLimitFilter and loginLockoutFilter)
are both added before UsernamePasswordAuthenticationFilter which leaves their
relative order implicit; update SecurityConfig so the rate limit filter is
explicitly placed before the login lockout filter and the login lockout filter
is placed before UsernamePasswordAuthenticationFilter to enforce RateLimitFilter
→ LoginLockoutFilter → UsernamePasswordAuthenticationFilter. Concretely, call
addFilterBefore(rateLimitFilter, LoginLockoutFilter.class) and then
addFilterBefore(loginLockoutFilter, UsernamePasswordAuthenticationFilter.class)
(using the actual filter implementation classes for LoginLockoutFilter) in the
SecurityConfig configuration where the filters are registered.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptService.java`:
- Around line 27-28: The emailAttempts and ipAttempts ConcurrentHashMaps in
LoginAttemptService grow unbounded (entries only added in registerFailure and
removed on success); add time-based eviction: either replace
emailAttempts/ipAttempts with Caffeine caches configured with
expireAfterAccess(failureWindowSeconds + lockDurationSeconds, SECONDS) and a
reasonable maximumSize, or implement a lightweight scheduled cleanup in
LoginAttemptService constructor that periodically iterates emailAttempts and
ipAttempts and removes entries where AttemptState.lockedUntilEpochSecond <= now
AND (now - AttemptState.windowStartEpochSecond) >= failureWindowSeconds;
reference the fields emailAttempts, ipAttempts, the registerFailure method,
AttemptState, and the configuration values
failureWindowSeconds/lockDurationSeconds when adding the eviction logic and
scheduling the task.
- Around line 75-78: The log lines in LoginAttemptService are writing raw,
attacker-controlled PII (email and clientIp) and allow log-forgery because
normalizeEmail only trims/lowers; fix by introducing a logging-only sanitizer
and pseudonymizer (e.g., a helper method like pseudonymizeForLogs(String value,
String appSalt) that first removes CR/LF and other control chars then computes a
truncated SHA-256 hash or returns only the email domain for emails and a
hashed/truncated IP), keep using normalizeEmail/normalizeIp for internal keys
and rate-limits but replace the values passed to log.info/log.warn in the
methods that emit
"security_event=LOGIN_FAILED"/"security_event=LOGIN_LOCKED"/etc. with the
sanitized/pseudonymized outputs from that helper, and ensure the helper is
reused across all three log sites to avoid duplication.
- Around line 83-89: recordSuccess currently removes both emailAttempts and
ipAttempts which resets IP-level counters and undermines per-IP lockouts; change
recordSuccess(String email, String clientIp) to only remove
normalizeEmail(email) from emailAttempts and do not remove normalizeIp(clientIp)
from ipAttempts (or if you prefer a conservative approach, only remove the IP
entry when ipAttempts.get(normalizeIp(clientIp)).failureCount < THRESHOLD), then
keep the calls to securityObservabilityService.recordLoginSuccessReset() and
setActiveLoginLocks(nowEpochSecond()) and the log using
normalizeEmail/normalizeIp; this preserves per-IP protections while still
clearing per-email state.

In
`@src/main/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilter.java`:
- Line 38: LoginLockoutFilter currently uses request.getRemoteAddr() which
yields the proxy IP; update the code to extract the client IP from the
X-Forwarded-For header (parse the first non-empty entry) with fallback to
request.getRemoteAddr(), matching the logic used in AuditInterceptor. Replace
the single-line assignment of clientIp in LoginLockoutFilter and apply the same
extraction strategy in LoginAuthenticationFailureHandler (line ~33),
LoginAuthenticationSuccessHandler (line ~28) and RateLimitFilter (line ~49),
ensuring each place uses the X-Forwarded-For first value if present, otherwise
request.getRemoteAddr(), and keep the extraction logic consistent by reusing a
small helper method or inline the same parsing routine.

In
`@src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilter.java`:
- Line 49: The rate limiter is using request.getRemoteAddr() (in RateLimitFilter
and LoginLockoutFilter) which breaks behind proxies; update the app to obtain
the real client IP either by enabling Spring/Tomcat forwarded header processing
(e.g., set server.forward-headers-strategy=native and configure trusted
proxies/internal-proxies so RemoteIpValve parses X-Forwarded-For) or add a
forwarded-aware resolver (e.g., a helper used by RateLimitFilter and
LoginLockoutFilter that extracts the left-most trusted entry from
X-Forwarded-For with validation and falls back to getRemoteAddr()), and add a
short comment documenting the trusted-proxy model so headers aren’t blindly
trusted.

In
`@src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitService.java`:
- Line 25: The counters map (field counters in RateLimitService) is unbounded
and can leak memory; replace the ConcurrentMap<String, CounterWindow> with a
Caffeine Cache<String, CounterWindow> configured with expireAfterAccess or
expireAfterWrite (set to at least the largest policy window) and a sensible
maximumSize to evict old client entries, then update usages (where CounterWindow
instances are retrieved/created — e.g., the code paths that currently call
counters.get or put, and the logic around creating new
CounterWindow(nowEpochSecond)) to use cache.get(key, mappingFunction) so stale
entries are auto-evicted and the limiter cannot grow unbounded.

In `@src/main/resources/application.properties`:
- Around line 52-53: The properties enable Prometheus via
management.endpoints.web.exposure.include=health,info,metrics,prometheus but the
Micrometer Prometheus registry dependency is missing; either add the
io.micrometer:micrometer-registry-prometheus dependency to your build (so the
prometheus actuator endpoint is registered) or remove "prometheus" from the
exposure list in application.properties; note actuator endpoints are already
protected by the catch-all .anyRequest().authenticated() rule in SecurityConfig,
so no additional authorization rules are required.

In `@src/main/resources/static/app.css`:
- Around line 717-726: Keyframe names panelGlow and shimmerSweep violate
kebab-case; rename them to panel-glow and shimmer-sweep in the `@keyframes` blocks
and update every CSS usage that references them (e.g., any animation: or
animation-name: properties that currently use panelGlow or shimmerSweep) to use
the new kebab-case identifiers so stylelint passes.

In `@src/main/resources/static/app.js`:
- Around line 134-136: The resize handler currently calls syncBySlide(), which
only redraws using the existing activeSlideIndex and can leave the nav
out-of-sync; replace the call to syncBySlide() with
syncActiveSlideFromViewport() so the handler re-derives the active slide from
the viewport (syncActiveSlideFromViewport already falls back to updateProgress()
when the index doesn't change).

---

Nitpick comments:
In
`@src/main/java/org/example/projektarendehantering/infrastructure/config/SecurityConfig.java`:
- Around line 69-70: The two filters (rateLimitFilter and loginLockoutFilter)
are both added before UsernamePasswordAuthenticationFilter which leaves their
relative order implicit; update SecurityConfig so the rate limit filter is
explicitly placed before the login lockout filter and the login lockout filter
is placed before UsernamePasswordAuthenticationFilter to enforce RateLimitFilter
→ LoginLockoutFilter → UsernamePasswordAuthenticationFilter. Concretely, call
addFilterBefore(rateLimitFilter, LoginLockoutFilter.class) and then
addFilterBefore(loginLockoutFilter, UsernamePasswordAuthenticationFilter.class)
(using the actual filter implementation classes for LoginLockoutFilter) in the
SecurityConfig configuration where the filters are registered.

In
`@src/main/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilter.java`:
- Around line 47-48: The redirect currently URL-encodes a numeric retryAfter
value unnecessarily; in LoginLockoutFilter replace the URLEncoder.encode usage
and append decision.retryAfterSeconds() directly (or its String form) when
building the "/login?error=true&locked=true&retryAfter=" redirect, and make the
same change in LoginAuthenticationFailureHandler at the analogous location (line
~38) so the numeric seconds are inlined without encoding.

In `@src/main/resources/static/app.js`:
- Around line 110-133: Thottle scroll handler by coalescing
syncActiveSlideFromViewport calls with requestAnimationFrame: add a module-level
flag (e.g., isTicking) and in the scroll listener (the
window.addEventListener("scroll", ...)) only schedule
syncActiveSlideFromViewport via requestAnimationFrame when not already
scheduled, clearing the flag inside syncActiveSlideFromViewport after work
completes; ensure the listener remains passive and keep references to
slides/getBoundingClientRect logic as-is so goToSlide-triggered programmatic
scrolls are coalesced and avoid repeated forced layouts.
- Around line 302-310: Replace the two goToSlide calls and the if/else block
with a single call: determine hash and initialIndex as before, set
activeSlideIndex, then call goToSlide(activeSlideIndex, { behavior: "auto",
shouldScroll: !!hash, updateHash: false }); and remove the redundant first call
and the separate conditional branch (including the alternate updateProgress()
call) since goToSlide runs syncBySlide() and will handle progress/scrolling
appropriately.

In `@src/main/resources/templates/login/login.html`:
- Around line 24-26: Update the login template to display the lockout remaining
time provided by LoginLockoutFilter by reading the retryAfter request param
instead of the static phrase; inside the existing th:if="${param.locked}" block
use param.retryAfter (e.g. show "${param.retryAfter} seconds" when present,
fallback to the current "a few minutes" text) so users see the actual wait time
returned by LoginLockoutFilter.

In
`@src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptServiceTest.java`:
- Around line 13-51: Add tests to verify whether LoginAttemptService keys by
(username, ip) pair or by single dimension: create new test(s) that use
LoginAttemptService (constructor used in existing tests) and call recordFailure
for "user@example.com" from "127.0.0.1" enough times to hit the threshold, then
assert currentLockDecision("user@example.com","127.0.0.2").locked() is false
(different IP) and/or assert
currentLockDecision("other@example.com","127.0.0.1").locked() is false
(different user); also add the inverse case if needed to document behavior.
Target the same class and methods: LoginAttemptService.recordFailure,
LoginAttemptService.currentLockDecision, and LoginAttemptService.recordSuccess
to show expected per-IP vs per-user semantics.

In
`@src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationHandlersTest.java`:
- Around line 32-47: Add a new test that verifies normalization consistency
between the submitted username and the authentication principal: create a
LoginAuthenticationSuccessHandler with a mocked LoginAttemptService, simulate a
request whose form username parameter is a non-canonical value (e.g., " 
User@Example.com "), set request remoteAddr, build an Authentication whose
getName() returns the canonical "user@example.com", call
handler.onAuthenticationSuccess(request, response, authentication), and verify
LoginAttemptService.recordSuccess was called with the canonical username
("user@example.com") and the request IP; ensure the response redirect assertion
("/home") remains.

In
`@src/test/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilterTest.java`:
- Around line 12-50: Add tests to LoginLockoutFilterTest that exercise
non-matching requests: create cases invoking LoginLockoutFilter.doFilter with
(1) a GET "/login" request (MockHttpServletRequest with method "GET") and assert
the filter does not call redirect and the chain proceeded, (2) a POST to a
different path like "/register" or "/api/foo" and assert the same, and (3) a
POST "/login" with no "username" parameter to verify no NPE and that the request
is passed through; in each test mock LoginAttemptService.currentLockDecision
only if expected to be called (and verify it is not called for non-matching
requests) and use MockFilterChain/MockHttpServletResponse assertions
(response.getRedirectedUrl() == null, response.getStatus()==200 or chain
proceeded) to confirm correct behavior.

In
`@src/test/java/org/example/projektarendehantering/infrastructure/security/RateLimitServiceTest.java`:
- Around line 58-79: Extract the private MutableClock inner class used in
RateLimitServiceTest into a shared package-private test utility so
LoginAttemptServiceTest can reuse it; specifically, create a new test helper
class named MutableClock (matching the current constructor and overrides:
instant(), getZone(), withZone()) in a shared test package (e.g.,
infrastructure.security.testing) and replace the private inner MutableClock in
both RateLimitServiceTest and LoginAttemptServiceTest with imports of that
shared MutableClock. Ensure the new helper preserves the same API (constructor
taking Instant and the three overridden methods) and visibility so both tests
can access it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2daa6848-b16c-4256-bb6d-01fb5b1edf69

📥 Commits

Reviewing files that changed from the base of the PR and between 359bf05 and e46e3e4.

📒 Files selected for processing (20)
  • pom.xml
  • src/main/java/org/example/projektarendehantering/infrastructure/config/SecurityConfig.java
  • src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptService.java
  • src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationFailureHandler.java
  • src/main/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationSuccessHandler.java
  • src/main/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilter.java
  • src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilter.java
  • src/main/java/org/example/projektarendehantering/infrastructure/security/RateLimitService.java
  • src/main/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityService.java
  • src/main/resources/application.properties
  • src/main/resources/static/app.css
  • src/main/resources/static/app.js
  • src/main/resources/templates/landing.html
  • src/main/resources/templates/login/login.html
  • src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAttemptServiceTest.java
  • src/test/java/org/example/projektarendehantering/infrastructure/security/LoginAuthenticationHandlersTest.java
  • src/test/java/org/example/projektarendehantering/infrastructure/security/LoginLockoutFilterTest.java
  • src/test/java/org/example/projektarendehantering/infrastructure/security/RateLimitFilterTest.java
  • src/test/java/org/example/projektarendehantering/infrastructure/security/RateLimitServiceTest.java
  • src/test/java/org/example/projektarendehantering/infrastructure/security/SecurityObservabilityServiceTest.java

Comment thread src/main/resources/application.properties Outdated
Comment thread src/main/resources/static/app.css Outdated
Comment thread src/main/resources/static/app.js
@LinusWestling
LinusWestling merged commit edba8f6 into main Apr 24, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nice to have - Rate Limiting/Brute Force

1 participant